Skip to main content

M-Bus to LoRaWAN - Multiframe Script

The manual describes use of the multiframe Lua Script for the ACR-CV-101L-M-D and ACR-CV-101L-M-EAC converters. This script is very effective for one to one meter installation while it allows for an easy basic configuration.

Part of the article is also Octave Meters Script version description made specifically to support these meters.

Introduction


Overview

The main use case for this script is to allow for a simple use of primary addressing, using LoRaWAN, while being able to keep the size of the payload with help of VIF DIF, that can be configured according to your needs. VIF DIF filtering allows the user to reduce the amount of payloads sent by the converter, as LoRaWAN has a limited payload size. This is especially usefull if you want to receive only specific data and reduce the battery consumption of the device, as well the traffic going through the LoRaWAN gate.

Here, you can find the script:

M-Bus to LoRaWAN - Multiframe Lua script

Primary Addressing

Primary addressing refers to a meter at specific ID in form of a number, for which the converter is looking when reading out the devices.

note

Make sure, the primary addresses of multiple meters are not conflicting (same) when connected to a single converter. The converter can read out multiple devices (it has capacity of up to 16 UL).

In case that primary address conflict happens, you need to set the primary address of the conflicting meter to a different identifier.

AddressFunction
0Factory default address.
1-250Addresses that can be assigned to slaves. (Primary addresses)
251-252Reserved for future use.
253Indicates that addressing is performed at the network layer instead. (Secondary addressing procedure)
254Broadcast, meters reply with their addresses. (Causes collision with multiple meters)
255Broadcast, meters do not reply.

Converter Integration

To integrate the device using the multiframe script, you need to either write the script into the device, or alternatively order the devices pre-configured with the script already. To learn how to successfully write the script into the device, check the following article or the tutorial section under the Help tab within the GUI itself.


Functions

  • Configurable M-Bus reading and sending period (default interval: 15 minutes)
  • Configurable initial M-bus delay (default length: 5 seconds)
  • Configurable list of primary addresses (needed for multiple devices, otherwise the meter is read out automatically)
  • Configurable VIF DIF filtering (used to reduce the telegram size to fit into a single LoRaWAN frame)
  • Remote configuration using specific commands (options such as restarting the device, formatting eeprom and more)

Remote Device Configuration

First, the device has to be installed. You can read more about it in quick start guide for ACR-CV-L-M-D and ACR-CV-L-M-EAC.

For the remote configuration, you are going to need to register the converter on a service such as The Thing Network. For more information about TTN integration, check the following article.

Once the device is set up on the ground and connected, you need to register it and make sure it is connected to the network.

The following can be remotely configured via downlink:

1. Initial Delay

2. Readout Period

3. VIF DIF Filter

4. Primary Address

5. Device Reset

6. Custom Lua Command

HEX Conversion

The downlink payloads have to be sent in HEX downlink payload. Check the following mini guide to see how to do the HEX conversion.

HEX Conversion Mini Guide

Number conversion

If the downlink is in form of a number, following needs to be done.

For number conversion, use HEX converter such as this one

  1. Enter a number you want to convert, e.g. 4000

HEX number conversion

  1. Now use the little endian version, which would be A0 0F in this case.

  2. This would be the downlink message, if you were to change e.g. initial delay. Make sure that there is a space behind the last byte for the downlink to be valid.

If you were to convert a number, that has one byte only, but the requirement for payload is 2 bytes, simply replace the second byte (or other amount of unused bytes) with 00

For example, 0A is 10, therefore downlink would look like this: 0A 00

Text conversion

For text conversion, use HEX converter such as this one

This would be used mostly for custom Lua commands in case of this script.

  1. Enter a text you would like to convert, e.g. print("Hi")

HEX string conversion

  1. And simply copy the converted HEX payload, which would be 70 72 69 6E 74 28 22 48 69 22 29 in this case.

  2. This would be the downlink message.

Once you have the payload you want to send converted to HEX format, send the downlink via the service you use. Here is the explanation of the items, that are described in the individual downlink commands:

Default: Explains what the default configuration is.

Port: This is a port number, you have to fill in appropriately. In this case 20 to 27. Each port number represents individual downlink command.

Payload: This is what you are sending into the device in HEX format as shown in miniguide above.

Payload length: Sometimes, the payload has to have exact length. Sometimes it may vary, depending on the configuration.

Example of downlink payload: Showcases an example, like this: B8 0B along with the explanation of what the example means, like this: - this would be 3000 ms - 3 seconds initial delay.

Here is a mini guide on how to use The Things Network to send down a downlink command to configure the device.

Downlink Sending Mini Guide

This downlink guide expects a use of The Things Network, many other services allow downlink sending as well.

From then on, you can use the following downlink commands to configure the device. This specific script supports the following:

  1. Go to The Things Network and click on Applications in the upper left menu.

HEX string conversion

  1. Select the application with the end device you want to send the downlink to.

HEX string conversion

  1. Select the End device which you want to send the downlink to.

HEX string conversion

  1. Select the Messaging tab. And write in appropriate port number - based on further documentation and enter the payload - also based on the further documentation. Then click on Schedule downlink.

HEX string conversion

  1. It should be confirmed by popup right down.

HEX string conversion

  1. You can now see the state of downlink and its reception in the Live data tab.

HEX string conversion

note

Note that the device can receive only one downlink at the time. This means that doing multiple configurations will take some time, as each is applied only with one device wake-up.

You can, however, queue them and eventually all of them should happen.

Initial Delay Configuration


Initial delay is used to wake up device before reading it out. The longer the delay, the higher the battery consumption. Keep in mind though, that some amount of delay is necessary, with different M-Buses having different requirements. Check HEX conversion and Downlink sending guides above to see how use the downlink command.

Default delay is: 5000 ms - 5 seconds.

Port: 20

Payload: Number between 1000 to 10000 - miliseconds - in HEX format (little endian)

Payload length: 2 Bytes

Example of downlink payload: B8 0B - this would be 3000 ms - 3 seconds initial delay

ExampleDescriptionSizeByte Number
0xB8 0x0BInitial Delay[2B]1-2

Serial Line Snippets

The following can be seen if you read the device serial line:

[13:09:50:752] [  9005917][STDOUT]: Received Data at Port: 20
[13:09:50:752] B8 0B
[13:09:50:752] [ 9005946][STDOUT]: Initial delay
[13:09:50:752] [ 9005995][STDOUT]: Reloading the configuration!
[13:09:50:933] [  9006203][STDOUT]: Initial Delay (preheat time) in ms: 3000

Lua Code Snippet

This piece of Lua code is handling this downlink command.

Lua Code Snippet
if rxport == 20 then -- initial delay
print("Initial delay")
if #rcvd == 2 then
_, tmp = pu(rcvd, "<H")
api.setVar(60, tmp, true)
reload = true
end

Readout Period Configuration


Readout period sets the intervals, at which the device wakes up, reads the M-Bus(es) and sends the payloads to the user. The longer the period, the less energy is consumed by the device in long term. Check HEX conversion and Downlink sending guides above to see how use the downlink command.

Example of Battery Capacity
info

Example Scenario

We have 2 slave units of the 2.5UL size connected to the converter. The preheat time is 4 seconds (default). We are also using a single D battery (double D2 battery would power the device twice as long).

Reading and Sending IntervalEstimated Battery Lifetime
1 hour2 years
2 hours4 years
6 hours10+ years

For more detailed information, check the consumption calculator or contact us at support@acrios.com.

Default readout period: 15 minutes

Port: 21

Payload: Number that represents your preferred readout period in minutes - in HEX format (little endian)

Payload length: 2 Bytes

Example of downlink payload: 0A 00 - this would be 10 minutes, 68 01 - this would be 360 minutes: 6 hour interval, A0 05 - this would be 1440 minutes: one day interval

ExampleDescriptionSizeByte Number
0xA0 0x05Readout Period[2B]1-2

Serial Line Snippets

The following can be seen if you read the device serial line:

[13:15:21:978] [  9337051][STDOUT]: Received Data at Port: 21
[13:15:21:978] 68 01
[13:15:21:978] [ 9337080][STDOUT]: Period in minutes
[13:15:21:978] [ 9337129][STDOUT]: Reloading the configuration!
[13:15:22:169] [  9337389][STDOUT]: Period of readout in minutes: 360
[13:15:22:169] [ 9337414][STDOUT]: Done sending
[13:15:22:190] [ 9337463][STDOUT]: Sleep now, wake in 360mins...

Lua Code Snippet

This piece of Lua code is handling this downlink command.

Lua Code Snippet
 elseif rxport == 21 then -- period in minutes
if #rcvd == 2 then
print("Period in minutes")
_, tmp = pu(rcvd, "<H")
api.setVar(62, tmp, true)
reload = true
end

VIF DIF Filter Configuration


VIF DIF Filters are used to configure the payloads to reduce their size and filter only the relevant information. Keep in mind, if you use multiple devices, VIF DIF filter may be difficult. Ideally, use the converter with this script in one to one ratio, or with multiple devices of the same kind (therefore same VIF DIF filter is compatible). Check HEX conversion and Downlink sending guides above to see how use the downlink command.

Default: VIF DIF filter is turned off

Port: 22

Payload: VIF DIF filter configuration

Payload length: Depending on the command. Keep in mind, that the payload limit is 51 bytes.

Example of downlink payload: 64 16 F2 B1 A5 06 97 32 DE 9B E9 - this would specifically send only ID and fabrication details, timestamp and volume.

The filter was applied accordingly:

  1. A full payload from the device must be received first, in this case, it was following: 68 DC DC 68 08 08 72 94 22 10 23 24 34 33 07 24 00 00 00 0E 78 00 00 00 00 00 00 04 6D 33 0B 3D 34 04 13 00 00 00 00 04 93 3C 03 00 00 00 44 13 01 00 00 00 42 6C 21 31 84 01 13 FE FF FF FF 8201 6C 3F 31 C4 01 13 FE FF FF FF C2 01 6C 3C 32 84 02 13 FE FF FF FF 82 02 6C 3F 33 C4 02 13 00 00 00 00 C2 02 6C 01 01 84 03 13 00 00 00 00 82 03 6C 01 01 C4 03 13 00 00 00 00 C2 03 6C 01 01 84 04 13 00 00 00 00 82 04 6C 01 01 C4 04 13 00 00 00 00 C2 04 6C 01 01 84 05 13 00 00 00 00 82 05 6C 01 01 C4 05 13 00 00 00 00 C2 05 6C 01 01 84 06 13 01 00 00 00 82 06 6C 1E 3B C4 06 13 01 00 00 00 C2 06 6C 1F 3C 02 EC 7E 41 31 01 FD 28 0C 02 FD 17 08 00 02 FD 0E 04 03 02 FD 0F 3A 61 8C 16

When parsed with the parser, the following can be read out.

Parsed Result
{
"len": 226,
"type": "Data",
"l": 220,
"c": 8,
"a": 8,
"ci": 114,
"errors": [],
"fixed": false,
"id": 23102294,
"manId": "MAD",
"version": 51,
"deviceCode": 7,
"deviceType": "Water meter",
"accessN": 36,
"status": 0,
"data": [
{
"id": 0,
"dif": [
14
],
"vif": [
120
],
"type": "Fabrication No",
"value": 0,
"rawValue": [
0,
0,
0,
0,
0,
0
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 1,
"dif": [
4
],
"vif": [
109
],
"type": "Time Point (time & date)",
"value": {
"rawY": 25,
"y": 2025,
"m": 4,
"d": 29,
"hr": 11,
"mi": 51
},
"rawValue": [
51,
11,
61,
52
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 2,
"dif": [
4
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0,
"rawValue": [
0,
0,
0,
0
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 3,
"dif": [
4
],
"vif": [
147,
60
],
"type": "Volume",
"unit": "m³",
"typeE": [
"Accumulation of abs value only if negative contributions"
],
"value": 0.003,
"rawValue": [
3,
0,
0,
0
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 4,
"dif": [
68
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0.001,
"rawValue": [
1,
0,
0,
0
],
"func": "Instantaneous",
"storage": 1
},
{
"id": 5,
"dif": [
66
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 25,
"y": 2025,
"m": 1,
"d": 1
},
"rawValue": [
33,
49
],
"func": "Instantaneous",
"storage": 1
},
{
"id": 6,
"dif": [
132,
1
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": -0.002,
"rawValue": [
254,
255,
255,
255
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 2
},
{
"id": 7,
"dif": [
130,
1
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 25,
"y": 2025,
"m": 1,
"d": 31
},
"rawValue": [
63,
49
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 2
},
{
"id": 8,
"dif": [
196,
1
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": -0.002,
"rawValue": [
254,
255,
255,
255
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 3
},
{
"id": 9,
"dif": [
194,
1
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 25,
"y": 2025,
"m": 2,
"d": 28
},
"rawValue": [
60,
50
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 3
},
{
"id": 10,
"dif": [
132,
2
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": -0.002,
"rawValue": [
254,
255,
255,
255
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 4
},
{
"id": 11,
"dif": [
130,
2
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 25,
"y": 2025,
"m": 3,
"d": 31
},
"rawValue": [
63,
51
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 4
},
{
"id": 12,
"dif": [
196,
2
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0,
"rawValue": [
0,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 5
},
{
"id": 13,
"dif": [
194,
2
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 0,
"y": 2000,
"m": 1,
"d": 1
},
"rawValue": [
1,
1
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 5
},
{
"id": 14,
"dif": [
132,
3
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0,
"rawValue": [
0,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 6
},
{
"id": 15,
"dif": [
130,
3
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 0,
"y": 2000,
"m": 1,
"d": 1
},
"rawValue": [
1,
1
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 6
},
{
"id": 16,
"dif": [
196,
3
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0,
"rawValue": [
0,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 7
},
{
"id": 17,
"dif": [
194,
3
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 0,
"y": 2000,
"m": 1,
"d": 1
},
"rawValue": [
1,
1
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 7
},
{
"id": 18,
"dif": [
132,
4
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0,
"rawValue": [
0,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 8
},
{
"id": 19,
"dif": [
130,
4
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 0,
"y": 2000,
"m": 1,
"d": 1
},
"rawValue": [
1,
1
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 8
},
{
"id": 20,
"dif": [
196,
4
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0,
"rawValue": [
0,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 9
},
{
"id": 21,
"dif": [
194,
4
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 0,
"y": 2000,
"m": 1,
"d": 1
},
"rawValue": [
1,
1
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 9
},
{
"id": 22,
"dif": [
132,
5
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0,
"rawValue": [
0,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 10
},
{
"id": 23,
"dif": [
130,
5
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 0,
"y": 2000,
"m": 1,
"d": 1
},
"rawValue": [
1,
1
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 10
},
{
"id": 24,
"dif": [
196,
5
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0,
"rawValue": [
0,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 11
},
{
"id": 25,
"dif": [
194,
5
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 0,
"y": 2000,
"m": 1,
"d": 1
},
"rawValue": [
1,
1
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 11
},
{
"id": 26,
"dif": [
132,
6
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0.001,
"rawValue": [
1,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 12
},
{
"id": 27,
"dif": [
130,
6
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 24,
"y": 2024,
"m": 11,
"d": 30
},
"rawValue": [
30,
59
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 12
},
{
"id": 28,
"dif": [
196,
6
],
"vif": [
19
],
"type": "Volume",
"unit": "m³",
"value": 0.001,
"rawValue": [
1,
0,
0,
0
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 13
},
{
"id": 29,
"dif": [
194,
6
],
"vif": [
108
],
"type": "Time Point (date)",
"value": {
"rawY": 24,
"y": 2024,
"m": 12,
"d": 31
},
"rawValue": [
31,
60
],
"func": "Instantaneous",
"device": 0,
"tariff": 0,
"storage": 13
},
{
"id": 30,
"dif": [
2
],
"vif": [
236,
126
],
"type": "Time Point (date)",
"typeE": [
"future value"
],
"value": {
"rawY": 26,
"y": 2026,
"m": 1,
"d": 1
},
"rawValue": [
65,
49
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 31,
"dif": [
1
],
"vif": [
253,
40
],
"type": "Storage interval",
"unit": "months",
"value": 12,
"rawValue": [
12
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 32,
"dif": [
2
],
"vif": [
253,
23
],
"type": "Error flags",
"unit": "binary",
"value": 8,
"rawValue": [
8,
0
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 33,
"dif": [
2
],
"vif": [
253,
14
],
"type": "Firmware version #",
"value": 772,
"rawValue": [
4,
3
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 34,
"dif": [
2
],
"vif": [
253,
15
],
"type": "Software version #",
"value": 24890,
"rawValue": [
58,
97
],
"func": "Instantaneous",
"storage": 0
}
]
}
  1. The payload can be placed into the M-Bus Filter Tool where the size of different information units can be seen, and the relevant ones can be picked.

filter tool

  1. Once you have selected the data, head down to Generated Downlink Commands. Copy what is in the LoRaWAN field but before it can be used, you have to remove all the bytes until last FF - this is due to fact this tool is primarily used for configuration of different script.

00 01 00 60 09 03 B8 0B 02 00 FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 03 02 0E 78 02 04 6D 03 04 93 3C -> 03 02 0E 78 02 04 6D 03 04 93 3C

  1. Now send the HEX value via downlink using port 22. Note that the downlink must be 51 Bytes or smaller.

The following can be seen if you read the device serial line:

[14:12:24:656] [ 12759651][LORAMAC]: RX PORT     : 22
[14:12:24:656] [ 12759656][LORAMAC]: RX DATA :
[14:12:24:656] [ 12759660][LORAMAC]: 03 02 0E 78 02 04 6D 03 04 93 3C
[14:12:24:656] [ 12759834][STDOUT]: Received Data at Port: 22
[14:12:24:656] 03 02 0E 78 02 04 6D 03 04 93 3C
[14:12:24:656] [ 12759866][STDOUT]: VIF DIF filter
[14:12:25:130] [ 12760183][STDOUT]: Reloading the configuration!
[14:12:25:130] [ 12760290][STDOUT]: VIF DIF Filter:
[14:12:25:130] 03 02 0E 78 02 04 6D 03 04 93 3C
  1. Finally, the payload you receive should be filtered: 68 24 24 68 08 08 72 94 22 10 23 24 34 33 07 2B 00 00 00 0E 78 00 00 00 00 00 00 04 6D 31 0C 3D 34 04 93 3C 04 00 00 00 A4 16
Parsed Result
{
"len": 42,
"type": "Data",
"l": 36,
"c": 8,
"a": 8,
"ci": 114,
"errors": [],
"fixed": false,
"id": 23102294,
"manId": "MAD",
"version": 51,
"deviceCode": 7,
"deviceType": "Water meter",
"accessN": 43,
"status": 0,
"data": [
{
"id": 0,
"dif": [
14
],
"vif": [
120
],
"type": "Fabrication No",
"value": 0,
"rawValue": [
0,
0,
0,
0,
0,
0
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 1,
"dif": [
4
],
"vif": [
109
],
"type": "Time Point (time & date)",
"value": {
"rawY": 25,
"y": 2025,
"m": 4,
"d": 29,
"hr": 12,
"mi": 49
},
"rawValue": [
49,
12,
61,
52
],
"func": "Instantaneous",
"storage": 0
},
{
"id": 2,
"dif": [
4
],
"vif": [
147,
60
],
"type": "Volume",
"unit": "m³",
"typeE": [
"Accumulation of abs value only if negative contributions"
],
"value": 0.004,
"rawValue": [
4,
0,
0,
0
],
"func": "Instantaneous",
"storage": 0
}
]
}

Lua Code Snippet

This piece of Lua code is handling this downlink command.

Lua Code Snippet
elseif rxport == 22 then -- VIF DIF filter
_, tmp = pu(rcvd, "b")
print("VIF DIF filter")
if tmp ~= 0 then
api.setVar(22, "bytes", rcvd)
api.setVar(20, #rcvd, true)
else
api.setVar(20, 0, true)
end
reload = true

Primary Address Configuration


Primary address configuration is used to readout specific meters. The converter does not necessarily need to configure the addresses, if there is only one device but more than one meter would cause the payload to be mixed and thus useless. It is therefore recommended to scan for multiple M-Bus meters and their addresses once and then configure them with help of this command. Check HEX conversion and Downlink sending guides above to see how use the downlink command.

Default is broadcast - which can read out a single meter.

Port: 23

Payload: Individual primary address numbers (0-250) - in HEX format (little endian)

Payload length: 1 byte - for one address, 2 bytes - for two addresses, and more, scaling accordingly.

Example of downlink payload: 01 07 - this would be 2 meters at addresses 1 and 7

ExampleDescriptionSizeByte Number
0x01First address - primary address 1[1B]1
0x07Second address - primary address 7[1B]2
0x...Third...[1B]...

Serial Line Snippets

The following can be seen if you read the device serial line:

[15:54:28:497] [ 18883334][STDOUT]: Received Data at Port: 23
[15:54:28:497] 08
[15:54:28:497] [ 18883363][STDOUT]: List of primary addresses
[15:54:29:502] [ 18883497][STDOUT]: Reloading the configuration!
[15:54:29:502] [ 18883653][STDOUT]: List of MBus meters to read:
[15:54:29:502] 08

Lua Code Snippet

This piece of Lua code is handling this downlink command.

Lua Code Snippet
elseif rxport == 23 then -- list of primary addresses
_, tmp = pu(rcvd, "b")
print("List of primary addresses")
if tmp ~= 0 then
api.setVar(32, "bytes", rcvd)
api.setVar(30, #rcvd, true)
reload = true
end

Device Reset


This command triggers a device reset, which then restarts the device into bootloader, once the downlink is received. Keep in mind this keeps the configuration untouched. Check HEX conversion and Downlink sending guides above to see how use the downlink command.

Port: 24

Payload: Specific 4 bytes, that trigger the reset, as seen below in the example.

Payload length: 4 bytes

Example of downlink payload: 04 03 02 01 - in order to trigger the reset, the payload needs to look exactly like this

ExampleDescriptionSizeByte Number
0x04 0x03 0x02 0x01Restarts the device[4B]1-4

Serial Line Snippet

The following can be seen if you read the device serial line:

[16:03:37:170] [ 19432066][STDOUT]: Received Data at Port: 24
[16:03:37:170] 04 03 02 01
[16:03:37:170] [ 19432096][STDOUT]: Resetting...
[16:03:37:170] [ 19432104][LUA]: LUA RESET!
[16:03:37:308] c;r

The entry c;r in the log represents the device going through bootloader (which has a different baudrate setting).

Lua Code Snippet

This piece of Lua code is handling this downlink command.

Lua Code Snippet
elseif rxport == 24 then -- reset command
_, tmp = pu(rcvd, "<I")
if tmp == "0x01020304" then
print("Resetting...")
api.exec("_reset")
end

Custom Commands in Lua


This command allows the user to use any Lua script command using pcall function. Check HEX conversion and Downlink sending guides above to see how use the downlink command.

Port: 25

Payload: Text in HEX containing a command in Lua script.

Payload length: Depending on the command. Keep in mind, that the payload limit is 51 bytes.

Example of downlink payload: 61 70 69 2E 65 78 65 63 28 22 5F 66 6F 72 6D 61 74 5F 65 65 70 72 6F 6D 22 29 20 61 70 69 2E 65 78 65 63 28 22 5F 72 65 73 65 74 22 2C 31 29 - which would be api.exec("_format_eeprom") api.exec("_reset",1). This formats and clears eeprom and restarts the device without triggering the bootloader (api.exec("_reset") would trigger the bootloader).

In the following serial log you can see, that all the custom settings applied to the converter were reset to the default value with the custom command above in the example:

[16:15:56:141] [   646324][STDOUT]: Received Data at Port: 25
[16:15:56:141] 61 70 69 2E 65 78 65 63 28 22 5F 66 6F 72 6D 61 74 5F 65 65 70 72 6F 6D 22 29 20 61 70 69 2E 65 78 65 63 28 2
[16:15:56:141] [ 7596][LUA]: Starting onStartup() script
[16:15:56:141] [ 7629][STDOUT]: Starting up LoRaWAN MBUS script for primary addressing with remote configuration
[16:15:57:172] [ 8474][STDOUT]: VIF DIF Filter:
[16:15:57:172] 00
[16:15:57:172] [ 8512][STDOUT]: List of MBus meters to read:
[16:15:57:172] FE
[16:15:57:172] [ 8571][STDOUT]: Initial Delay (preheat time) in ms: 5000
[16:15:57:172] [ 8627][STDOUT]: Period of readout in minutes: 15
[16:15:57:328] [ 8830][LUA]: Starting onWake() script
warning

The maximal size of the downlink payload can be 51 bytes due to LoRaWAN limitations.

Demonstration: 01 02 03 04 05 06 would be a payload of size of 6 bytes.

ExampleDescriptionSizeByte Number
0x70 0x72 0x69 0x6E 0x74 0x28 0x22 0x48 0x65 0x6C 0x6C 0x6F 0x22 0x29print("Hello") - prints Hello in the console[14B]1-14

Serial Line Snippet

The following can be seen if you read the device serial line:

[16:20:04:884] [   256361][STDOUT]: Received Data at Port: 25
[16:20:04:884] 70 72 69 6E 74 28 22 48 65 6C 6C 6F 22 29
[16:20:04:884] [ 256414][STDOUT]: Hello

Lua Code Snippet

This piece of Lua code is handling this downlink command.

Lua Code Snippet
elseif rxport == 25 then -- execute custom lua request command
local ok, func, err = pcall(loadstring, rcvd)
if ok then
pcall(func)
elseif err then
print("Error executing: ", err)
end
func = nil
end

Octave Meters script variant

This is a variant of the Multiframe script, that however has custom additional functionalities, mainly focused on the Octave Meters specific primary addresing and different kind of M-Bus frame filtration.

The full script can be seen here:

The Octave Meters Script Variant
ver = "1.0"
---- CONFIGURATION ----
------- LoRaWAN -------
ack = 0 -- 1 for acknowledged, 0 for non-acknowledged
port = 100 -- transmit port
reportingPort = 101 -- reporting port for status messages
receiveTimeout = 10000 -- the maximum execution time in milliseconds
joinMethod = "OTAA" -- join method to use, "OTAA" or "ABP"
bootMsg = true -- send boot message to LoRaWAN
------- M-BUS ---------
baudrate = 2400 -- baudrate: up to 921600 baud
parity = 2 -- communication parity: 0 for none, 1 for odd and 2 for even parity
stopBits = 1 -- number of stop bits: 1 or 2
dataBits = 8 -- number of data bits: 7 or 8
initialDelay = 5000 -- (DEFAULT VALUE) delay before sending request - some devices require 3000 (Engelmann SensoStar), impacts battery life!

b = string.char

--[[
-- Example of pre-filtering
defaultVifDifFilter = pack.pack("b17", 0x05, -- (DEFAULT VALUE) number of DIF VIF values
0x02, 0x0E, 0x03, -- Energy
0x02, 0x0C, 0x15, -- Volume
0x02, 0x0B, 0x2F, -- Power
0x02, 0x0B, 0x3D, -- Volume flow
--0x02, 0x0A, 0x5A, -- Flow temperature
--0x02, 0x0A, 0x5E, -- Return temperature
0x03, 0x02, 0xFD, 0x17) -- Error flags --]]
defaultVifDifFilter = "\0" -- no pre-filtering

defaultListOfPrimaryOctaveAddresses = "\254" -- (DEFAULT VALUE) List of primary addresses to read, escape with \, for broadcast "\254" (0xFE)

defaultListOfPrimaryAddresses = "" -- (DEFAULT VALUE) List of primary addresses to read, escape with \, for broadcast "\254" (0xFE)


-- first configuration
defaultOctaveFiltration = b(1) -- lorawan port to use = 1
.. b(16) .. b(5) .. b(0x01) .. b(0x03) .. b(0x0D) .. b(0xFD) .. b(0x67) -- Special supplier information
.. b(2) .. b(5) .. b(0x01) .. b(0x03) .. b(0x32) .. b(0xFD) .. b(0x17) -- Error flags
.. b(6) .. b(4) .. b(0x01) .. b(0x02) .. b(0x06) .. b(0x6D) -- Time Point (time & date)
.. b(8) .. b(6) .. b(0x01) .. b(0x04) .. b(0x07) .. b(0x96) .. b(0xBB) .. b(0x73) -- Volume positive
.. b(8) .. b(6) .. b(0x01) .. b(0x04) .. b(0x07) .. b(0x96) .. b(0xBC) .. b(0x73) -- Volume negative
.. b(4) .. b(4) .. b(0x01) .. b(0x02) .. b(0x04) .. b(0x3B) -- Volume Flow
.. b(4) .. b(4) .. b(0x01) .. b(0x02) .. b(0x05) .. b(0x5B) -- Flow Temperature

--[[ -- second configuration
defaultOctaveFiltration = b(2) -- lorawan port to use = 2
.. b(16) .. b(5) .. b(0x01) .. b(0x03) .. b(0x0D) .. b(0xFD) .. b(0x67) -- Special supplier information
.. b(2) .. b(5) .. b(0x01) .. b(0x03) .. b(0x32) .. b(0xFD) .. b(0x17) -- Error flags
.. b(6) .. b(4) .. b(0x01) .. b(0x02) .. b(0x06) .. b(0x6D) -- Time Point (time & date)
.. b(8) .. b(6) .. b(0x01) .. b(0x04) .. b(0x07) .. b(0x96) .. b(0xBB) .. b(0x73) -- Volume positive
.. b(4) .. b(4) .. b(0x01) .. b(0x02) .. b(0x04) .. b(0x3B) -- Volume Flow
.. b(4) .. b(4) .. b(0x01) .. b(0x02) .. b(0x05) .. b(0x5B) -- Flow Temperature
.. b(8) .. b(5) .. b(0x01) .. b(0x03) .. b(0x07) .. b(0x96) .. b(0x73) -- Net Unsigned Volume
]]

------ Timing ---------
-- device wakeup interval (DEFAULT VALUE)
periodMinutes = 15
-- CONFIGURATION END --

ex = api.exec
pp = pack.pack
pu = pack.unpack
function b(d)
return pp("b", d)
end

function filterDataOctave(raw)
function getPartOrFF(raw, filter, datalen)
local filtered = api.mbusVifDifFilter("filter", raw, filter)
local ret = ""
if filtered:byte(2) ~= 15 then
if #filtered >= datalen + 2 then
ret = filtered:sub(#filtered - 1 - datalen, #filtered - 2)
else
print("Filtered data too short. filtered len mmm= " .. #filtered .. ", expected at least " .. (datalen + 2))
end
else
print("Using filler instead!")
for i = 1, datalen do
ret = ret .. string.char(0xFF) -- Adds FF if filter is not in payload
end
end
print("Data field after filter :")
api.dumpArray(ret, "raw")
return ret
end

local parts = {}

octavePort = octaveFiltration:byte(1) -- load lorawan port to use
local pos = 2
while true do
local thisLen = octaveFiltration:byte(pos+1)
parts[#parts + 1] =
getPartOrFF(
raw,
octaveFiltration:sub(pos+2, pos+1+thisLen),
octaveFiltration:byte(pos)
)
pos = pos + 2 + thisLen

if pos >= #octaveFiltration then
break
end
end

local toSend = ""
for i = 1, #parts do
toSend = toSend .. parts[i]
end

api.dumpArray(toSend, "raw")
return toSend
end

function bootMessage(joinMethod, rxTimeout, port)
local sysinfo = ex("_sysinfo")
model = sysinfo["model"]["name"]
sn = sysinfo["SN"]
fwma = sysinfo["ver"]["major"]
fwmi = sysinfo["ver"]["minor"]
fwbf = sysinfo["ver"]["bugfix"]
local battVol = api.getBatteryVoltage()
local cpuTemp = ex("_cpu_temp")
local bootMsg =
model .. b(0) .. pp("<i", sn) .. b(fwma) .. b(fwmi) .. b(fwbf) .. ver .. pp("<i", battVol) .. b(cpuTemp)
local msgPrint =
"Model: " ..
model ..
", Serial number: " ..
sn ..
", FW version: " ..
fwma ..
"." ..
fwmi ..
"." ..
fwbf ..
", Script version: " ..
ver ..
", Battery voltage: " ..
battVol .. " mV, CPU temperature: " .. cpuTemp .. " C"
if tonumber(fwma) >= 2 and tonumber(fwmi) >= 14 then
local eui = api.loraGetDevEui("bin")
bootMsg = eui .. bootMsg
local euiHEX = api.loraGetDevEui("hex")
msgPrint = "devEUI: " .. euiHEX .. ", " .. msgPrint
end
api.loraSetup("ACTIVATION", joinMethod)
api.loraSetup("CLASS", "A")
if joinMethod == "OTAA" then
api.loraJoin()
end
print("To LoRaWAN: ")
api.dumpArray(bootMsg, "raw")
local timeout = rxTimeout
for i = 1, 5 do
status, _, answer = api.loraSend(1, timeout, bootMsg, port)
if status >= 0 then
break
elseif i == 5 and status < 0 then
print("Failed to send boot message to LoRaWAN, restarting ...")
ex("_reset")
end
timeout = timeout + 2000
end
print("BOOT UP DONE! " .. msgPrint)
end

-- For parsing you can use this parser:
-- https://backend.wmbus.acrios.com/#/Parsers/parse_provided_input_as_hex_mbus_parser_mbus_hex_get
function mbusPrimaryReadout(addr, isLast, isOctave)
-- CREATE MBUS QUERY --
-- MBUS Query is being assembled as described in MBUS protocol documentation. In the example below broadcast adddress 254 (=0xFE) is used.
-- Documentation https://m-bus.com/documentation-wired/05-data-link-layer
-- Request for Class 2 Data - REQ_UD2
-- b=pack.pack('<b5', 0x10, 0x5B, 0xFE, 0x59, 0x16)
-- 0x10 - Start byte
-- 0x5B or 0x7B - C-Field - Request for Class 2 Data
-- 0xFE - Address field
-- 0x59 - CRC - calculated by (0x7B+0xFE)%256
-- 0x16 - Stop byte
b = pp("<b5", 0x10, 0x7B, addr, (0x7B + addr) % 256, 0x16)

status, _, _, _, _, raw = api.mbusTransaction(b, 3000, 3)
if isLast then
api.mbusState(0)
end

print("From MBus Meter: ")

api.dumpArray(raw, "raw")

if #raw < 1 then
if (not isLast) or hadSuccess then
return
else
buf =
pp(
"b21",
0x68,
0x0F,
0x0F,
0x68,
0x08,
0x01,
0x72,
0x00,
0x00,
0x00,
0x00,
0x72,
0x04,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x0F,
0x16
)
end
else
hadSuccess = true
buf = raw
end

-- adjust fragmentation according to current DR
local currentDR = api.loraSetup("DR")
local payload_size
if currentDR <= 2 then
payload_size = 51
elseif currentDR == 3 then
payload_size = 115
else
payload_size = 242
end

local skip = 0
local lastAcceptedIndex
local isDone

print(
"Fragmenting MBus frame into shorter frames with maximum size of " ..
tostring(payload_size) .. " bytes (using data rate DR" .. tostring(currentDR) .. ")"
)

while true do
if isOctave then
data = filterDataOctave(buf)
isDone = 1
else
data, lastAcceptedIndex, _, isDone = api.mbusVifDifFilter("filter", buf, skip, payload_size)
skip = lastAcceptedIndex + 1
end

api.dumpArray(data, "raw")
res, rxport, rcvd = api.loraSend(ack, receiveTimeout, data, isOctave and octavePort or port)
if rcvd ~= nil then
print("Received Data at Port: " .. tostring(rxport))
api.dumpArray(rcvd, "raw")
local tmp
if rxport == 20 then -- initial delay
print("Initial delay")
if #rcvd == 2 then
_, tmp = pu(rcvd, "<H")
api.setVar(60, tmp, true)
reload = true
end
elseif rxport == 21 then -- period in minutes
if #rcvd == 2 then
print("Period in minutes")
_, tmp = pu(rcvd, "<H")
api.setVar(62, tmp, true)
reload = true
end
elseif rxport == 22 then -- VIF DIF filter
_, tmp = pu(rcvd, "b")
print("VIF DIF filter")
if tmp ~= 0 then
api.setVar(22, "bytes", rcvd)
api.setVar(20, #rcvd, true)
else
api.setVar(20, 0, true)
end
reload = true
elseif rxport == 23 then -- list of primary addresses
_, tmp = pu(rcvd, "b")
print("List of primary addresses")
if tmp ~= 0 then
api.setVar(32, "bytes", rcvd)
api.setVar(30, #rcvd, true)
reload = true
end
elseif rxport == 26 then -- list of primary addresses with octave filtration
_, tmp = pu(rcvd, "b")
print("List of octave primary addresses")
if tmp ~= 0 then
api.setVar(72, "bytes", rcvd)
api.setVar(70, #rcvd, true)
reload = true
end
elseif rxport == 27 then -- list of Octave filters
_, tmp = pu(rcvd, "b")
print("List of Octave filters")
if tmp ~= 0 then
api.setVar(102, "bytes", rcvd)
api.setVar(100, #rcvd, true)
reload = true
end
elseif rxport == 24 then -- reset command
_, tmp = pu(rcvd, "<I")
if tmp == "0x01020304" then
print("Resetting...")
api.exec("_reset")
end
elseif rxport == 25 then -- execute custom lua request command
local ok, func, err = pcall(loadstring, rcvd)
if ok then
pcall(func)
elseif err then
print("Error executing: ", err)
end
func = nil
end
end

if isDone then
return
end
end
end

function loadConfiguration()
vifDifFilterLength = api.getVar(20, -1)

if vifDifFilterLength == -1 then
api.setVar(22, "bytes", defaultVifDifFilter)
api.setVar(20, #defaultVifDifFilter, true)
vifDifFilterLength = #defaultVifDifFilter
end
if vifDifFilterLength ~= 0 then
api.mbusVifDifFilter("populate", api.getVar(22, "bytes", vifDifFilterLength))
end

local lengthOflistOfPrimaryAddresses = api.getVar(30, 0)
if lengthOflistOfPrimaryAddresses == 0 then
api.setVar(32, "bytes", defaultListOfPrimaryAddresses)
api.setVar(30, #defaultListOfPrimaryAddresses, true)
listOfPrimaryAddresses = defaultListOfPrimaryAddresses
else
listOfPrimaryAddresses = api.getVar(32, "bytes", lengthOflistOfPrimaryAddresses)
end

local lengthOflistOfPrimaryOctaveAddresses = api.getVar(70, 0)
if lengthOflistOfPrimaryOctaveAddresses == 0 then
api.setVar(72, "bytes", defaultListOfPrimaryOctaveAddresses)
api.setVar(70, #defaultListOfPrimaryOctaveAddresses, true)
listOfPrimaryOctaveAddresses = defaultListOfPrimaryOctaveAddresses
else
listOfPrimaryOctaveAddresses = api.getVar(72, "bytes", lengthOflistOfPrimaryOctaveAddresses)
end


local lengthOfOctaveFiltration = api.getVar(100, 0)
if lengthOfOctaveFiltration == 0 then
api.setVar(102, "bytes", defaultOctaveFiltration)
api.setVar(100, #defaultOctaveFiltration, true)
octaveFiltration = defaultOctaveFiltration
else
octaveFiltration = api.getVar(102, "bytes", lengthOfOctaveFiltration)
end

initialDelay = api.getVar(60, initialDelay)
periodMinutes = api.getVar(62, periodMinutes)

if vifDifFilterLength ~= 0 then
print("VIF DIF Filter: ")
api.dumpArray(api.getVar(22, "bytes", vifDifFilterLength), "raw")
else
print("VIF DIF Filter off!")
end

print("List of MBus meters to read:")
api.dumpArray(listOfPrimaryAddresses, "raw")

print("List of Octave MBus meters to read:")
api.dumpArray(listOfPrimaryOctaveAddresses, "raw")

print("Octave filtration settings:")
api.dumpArray(octaveFiltration, "raw")

print("Initial Delay (preheat time) in ms: " .. tostring(initialDelay))
print("Period of readout in minutes: " .. tostring(periodMinutes))
end

function onWake()
-- set link parameters - 2400 baud, 8E1
api.mbusSetup(baudrate, parity, stopBits, dataBits)
api.mbusState(1)
api.delayms(initialDelay)

if vifDifFilterLength ~= 0 then
api.mbusVifDifFilter("activate", 0)
else
api.mbusVifDifFilter("deactivate")
end

local metersNumber = #listOfPrimaryAddresses
local octaveMetersNumber = #listOfPrimaryOctaveAddresses
reload = false
hadSuccess = false
for i = 1, metersNumber do
mbusPrimaryReadout(listOfPrimaryAddresses:byte(i), (i == metersNumber) and (octaveMetersNumber == 0), false)
end

for i = 1, octaveMetersNumber do
mbusPrimaryReadout(listOfPrimaryOctaveAddresses:byte(i), i == octaveMetersNumber, true)
end

if reload then
print("Reloading the configuration!")
loadConfiguration()
end

print("Done sending")
print("Sleep now, wake in " .. tostring(periodMinutes) .. "mins...")

api.wakeUpIn(0, 0, periodMinutes, 0)
end

function onStartup()
print("Starting up LoRaWAN MBUS script for primary addressing with remote configuration and Octave support")

local sysinfo = ex("_sysinfo")
local vercombined = sysinfo["ver"]["major"]*10000+sysinfo["ver"]["minor"]*100+sysinfo["ver"]["bugfix"]
if vercombined < 21406 then
print("Please perform firmware update!")
ex("_reset")
end

loadConfiguration()

if bootMsg then
bootMessage(joinMethod, receiveTimeout, reportingPort)
end
bootMessage = nil
onStartup = nil
end


--[[
The Things Stack parser for Octave:
- copy paste code between '---' to Payload formatters -> Uplink and select "Custom Javascript formatter"
- now in Live Data tab, you should see parsed frame

Example:
Single error:
Payload : 000037343138313030303130445241380100011C0838340039EDE300000000009893060000000000863D000000000000
Result:
{
"device_time": "2025-04-24T08:28:00",
"error_flags": "Units Change",
"error_flags_raw": "0100",
"flow_temperature_degC": 0,
"net_unsigned_volume": 15.75,
"serial": "ARD0100018147",
"volume_flow_m3h": 431,
"volume_positive_m3": 14937.401
}

Multiple errors :
Payload : 000037343138313030303130445241380505011C0838340039EDE300000000009893060000000000863D000000000000
Result:
{
"device_time": "2025-04-24T08:28:00",
"error_flags": "Leakage|Reverse Flow|Units Change|Service Required",
"error_flags_raw": "0505",
"flow_temperature_degC": 0,
"net_unsigned_volume": 15.75,
"serial": "ARD0100018147",
"volume_flow_m3h": 431,
"volume_positive_m3": 14937.401
}
---

/**
* Converts a byte array representing a packed date-time into an ISO-formatted string.
* Handles optional flags like summer time and invalid status.
*
* @param {Uint8Array | number[]} byteArray - The input byte array.
* @returns {string|null} ISO-formatted date-time string or null if invalid.
*/
function parsePackedDateTime(byteArray) {
const getLength = (arr) => (arr ? arr.length : 0);

// Convert little-endian byte array to integer
function bytesToIntLE(arr) {
let result = 0;
for (let i = arr.length - 1; i >= 0; i--) {
result = (result << 8) + arr[i];
}
return result;
}

// Decode packed date (year/month/day) from integer
function decodeDate(i) {
if (!i) return null;
const y = ((i >> 5) & 0x07) | ((i >> 9) & 0x78);
const m = (i >> 8) & 0x0F;
const d = i & 0x1F;
const year = 1900 + y + (y < 100 ? 100 : 0);
return { year, month: m, day: d };
}

const length = getLength(byteArray);
const hasExtra = length > 5 ? 1 : 0;
const lastByte = hasExtra ? byteArray[byteArray.length - 1] : 0;

const coreBytes = hasExtra ? byteArray.slice(hasExtra, length - 1) : byteArray;
const packedInt = bytesToIntLE(coreBytes);

if (!packedInt) return null;

const date = decodeDate(packedInt >> 16);
if (!date) return null;

date.hour = (packedInt >> 8) & 0x1F;
date.minute = packedInt & 0x3F;

if (hasExtra) {
date.second = lastByte & 0x3F;
}
if (packedInt & 0x8000) {
date.summer_time = true;
}
if (packedInt & 0x80) {
date.invalid = true;
}

let iso = `${date.year.toString().padStart(4, '0')}-${date.month.toString().padStart(2, '0')}-${date.day.toString().padStart(2, '0')}T${date.hour.toString().padStart(2, '0')}:${date.minute.toString().padStart(2, '0')}`;
if ("second" in date) {
iso += `:${date.second.toString().padStart(2, '0')}`;
}
if (date.summer_time) {
iso += " (summer)";
}
if (date.invalid) {
iso += " (invalid)";
}

return iso;
}

/**
* Parser: Filtered T-MBus Water Meter Data (Legacy-Compatible)
*/
function decodeUplink(input) {
var bytes = input.bytes;
var parsed = {};

// Check if all bytes in a segment are 0xFF (means missing)
function isFF(start, length) {
for (var i = 0; i < length; i++) {
if (bytes[start + i] !== 0xFF) return false;
}
return true;
}

// Decode ASCII characters
function readAscii(start, length) {
if (isFF(start, length)) return null;
var str = '';
for (var i = 0; i < length; i++) {
str += String.fromCharCode(bytes[start + i]);
}
return str;
}

// Convert byte array to uppercase hex string
function readHex(start, length) {
if (isFF(start, length)) return null;
var hex = '';
for (var i = 0; i < length; i++) {
var h = bytes[start + i].toString(16).toUpperCase();
if (h.length < 2) h = '0' + h;
hex += h;
}
return hex;
}

// Parse error flags into human-readable string
function parseErrorFlags(hexStr) {
if (!hexStr) return null;
const value = parseInt(hexStr, 16);
if (value === 0) return "OK";

const flags = [
{ bit: 0x0001, name: "Leakage" },
{ bit: 0x0002, name: "Pipe Burst" },
{ bit: 0x0004, name: "Reverse Flow" },
{ bit: 0x0008, name: "Dry" },
{ bit: 0x0010, name: "Critical Configuration" },
{ bit: 0x0020, name: "Measurement Fail" },
{ bit: 0x0040, name: "Tamper" },
{ bit: 0x0080, name: "Battery" },
{ bit: 0x0100, name: "Units Change" },
{ bit: 0x0200, name: "Watch Dog" },
{ bit: 0x0400, name: "Service Required" }
];

const errors = flags
.filter(flag => value & flag.bit)
.map(flag => flag.name);

return errors.length > 0 ? errors.join("|") : "OK";
}

// Wrapper to use packed datetime parser
function readPackedDateTime(start, length) {
if (isFF(start, length)) return null;
var slice = bytes.slice(start, start + length);
return parsePackedDateTime(slice);
}

// Decode 64-bit little-endian integer
function readInt64LE(start) {
if (isFF(start, 8)) return null;
var value = 0;
for (var i = 7; i >= 0; i--) {
value = value * 256 + bytes[start + i];
}
return value;
}

// Decode 32-bit unsigned little-endian integer
function readUInt32LE(start) {
if (isFF(start, 4)) return null;
var value = 0;
for (var i = 3; i >= 0; i--) {
value = value * 256 + bytes[start + i];
}
return value;
}

// Normalize raw serial to pattern like "ARD2000018147"
function decodeSerial(raw) {
var cleaned = '';
for (var i = raw.length - 1; i >= 0; i--) {
var c = raw.charCodeAt(i);
if (c >= 32 && c <= 126) {
cleaned += raw[i];
}
}

return cleaned;
}

// Start decoding from byte 0
var offset = 0;

if (input.fPort == 1)
{
var serial_raw = readAscii(offset, 16);
parsed.serial = decodeSerial(serial_raw);
offset += 16;

parsed.error_flags = parseErrorFlags(readHex(offset, 2));
parsed.error_flags_raw = readHex(offset, 2);
offset += 2;

parsed.device_time = readPackedDateTime(offset, 6);
offset += 6;

parsed.volume_positive_m3 = readInt64LE(offset) / 1000;
offset += 8;

parsed.volume_negative_m3 = readInt64LE(offset) / 1000;
offset += 8;

parsed.volume_flow_m3h = readUInt32LE(offset) / 1000; // Flow is in thousandths
offset += 4;

parsed.flow_temperature_degC = readUInt32LE(offset) / 1000; // Temperature is in thousandths
offset += 4;
}
else if(input.fPort == 2)
{
var serial_raw = readAscii(offset, 16);
parsed.serial = decodeSerial(serial_raw);
offset += 16;

parsed.error_flags = parseErrorFlags(readHex(offset, 2));
parsed.error_flags_raw = readHex(offset, 2);
offset += 2;

parsed.device_time = readPackedDateTime(offset, 6);
offset += 6;

parsed.volume_positive_m3 = readInt64LE(offset) / 1000;
offset += 8;

parsed.volume_flow_m3h = readUInt32LE(offset) / 1000; // Flow is in thousandths
offset += 4;

parsed.flow_temperature_degC = readUInt32LE(offset) / 1000; // Temperature is in thousandths
offset += 4;

parsed.net_unsigned_volume = readInt64LE(offset) / 1000; // volume
offset += 8;
}
else
{
}

return {
data: parsed,
warnings: [],
errors: []
};
}
---

]]

The script uses a different variant to what would be usually handled by VIF DIF filtration.

Check the comments in this Lua snippet for an explanation on how this filter works.

defaultOctaveFiltration = b(1)  -- this byte decides what port to use
.. b(16) -- first byte: b(16) decides the length of the line,
.. b(5) -- the second byte: tells how many individual bytes are expected, and if no info comes, they are filled with 'FF'
.. b(0x01) .. b(0x03) .. b(0x0D) .. b(0xFD) .. b(0x67) -- these are the bytes that are checked for on each individual line, check the whole script below for the actual implementation

Then, once the payload is created it is received and parsed with a help of a Javascript parser. Check the guide to set up the parser further below.

The result can look like this:

Parsed Json Example
{
"name": "as.up.data.forward",
"time": "2025-04-30T12:58:11.484604117Z",
"identifiers": [
{
"device_ids": {
"device_id": "lmd-sn-17001",
"application_ids": {
"application_id": "testingrpr"
},
"dev_eui": "70B3D5CEE0000D8A",
"join_eui": "0000000000000000",
"dev_addr": "01BDAC63"
}
}
],
"data": {
"@type": "type.googleapis.com/ttn.lorawan.v3.ApplicationUp",
"end_device_ids": {
"device_id": "lmd-sn-17001",
"application_ids": {
"application_id": "testingrpr"
},
"dev_eui": "70B3D5CEE0000D8A",
"join_eui": "0000000000000000",
"dev_addr": "01BDAC63"
},
"correlation_ids": [
"gs:uplink:01JT3CDCX58JM99F8BFB48ZGY3",
"rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01JT3CDCX9AF2DSZ7HZH9QB4P9",
"rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01JT3CDD437C66T3PH92TCBVNV"
],
"received_at": "2025-04-30T12:58:11.460718316Z",
"uplink_message": {
"session_key_id": "AZaGDrh4eTOFHiSERK9qqA==",
"f_port": 1,
"f_cnt": 33,
"frm_payload": "AAA3NDE4MTAwMDEwRFJBOAEAAToOPjQAXxUIAQAAAACYkwYAAAAAAIY9AAAAAAAA",
"decoded_payload": {
"device_time": "2025-04-30T14:58:00",
"error_flags": "0100",
"flow_temperature_degC": 0,
"serial": "ARD0100018147",
"volume_flow_m3h": 15.75,
"volume_negative_m3": 431,
"volume_positive_m3": 17306.975
},
"rx_metadata": [
{
"gateway_ids": {
"gateway_id": "rak-rpr-vyvoj",
"eui": "AC1F09FFFE051F8F"
},
"timestamp": 1083069027,
"rssi": -88,
"channel_rssi": -88,
"snr": 10.8,
"uplink_token": "ChsKGQoNcmFrLXJwci12eXZvahIIrB8J//4FH48Q46S5hAQaCwjjvMjABhCv9sRvILilx9/Conw=",
"channel_index": 3,
"received_at": "2025-04-30T12:58:11.227757837Z"
}
],
"settings": {
"data_rate": {
"lora": {
"bandwidth": 125000,
"spreading_factor": 7,
"coding_rate": "4/5"
}
},
"frequency": "867100000",
"timestamp": 1083069027
},
"received_at": "2025-04-30T12:58:11.242494485Z",
"consumed_airtime": "0.112896s",
"network_ids": {
"net_id": "000000"
}
}
},
"correlation_ids": [
"gs:uplink:01JT3CDCX58JM99F8BFB48ZGY3",
"rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01JT3CDCX9AF2DSZ7HZH9QB4P9",
"rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01JT3CDD437C66T3PH92TCBVNV"
],
"origin": "e1d41223d5fd",
"context": {},
"visibility": {
"rights": [
"RIGHT_APPLICATION_TRAFFIC_READ"
]
},
"unique_id": "01JT3CDD4WC215Z4K5A77X258K"
}

For further details, check the:

note

Note that the script posseses all the aforementioned configuration functionalities as well.

Remote Device Configuration for Octave Meters Script Variant

The following commands are described in this section:

1. Primary Octave Meter Address Configuration

2. Octave Meter Filter Selection

note

It is important to note, that this is a documentation specific to a derived script - therefore will not work with the base script.

Setting Primary Addresses with Octave Filtration


Primary address configuration is used to readout specific meters. The converter does not necessarily need to configure the addresses, if there is only one device but more than one meter would cause the payload to be mixed and thus useless. It is therefore recommended to scan for multiple M-Bus meters and their addresses once and then configure them with help of this command. Check HEX conversion and Downlink sending guides above to see how use the downlink command.

Default is broadcast - which can reads out a single meter.

Port: 26

Payload: Individual primary address numbers (0-250) - in HEX format (little endian)

Payload length: 1 byte - for one address, 2 bytes - for two addresses, and more, scaling accordingly.

Example of downlink payload: 01 07 - this would be 2 meters at addresses 1 and 7

ExampleDescriptionSizeByte Number
0x01First address - primary address 1[1B]1
0x07Second address - primary address 7[1B]2
0x...Third...[1B]...

Serial Line Snippets

The following can be seen if you read the device serial line:

[ 62870828][STDOUT]: Received Data at Port: 26
01 07
[ 62870858][STDOUT]: List of octave primary addresses
[ 62870932][STDOUT]: Reloading the configuration!
[ 62871228][STDOUT]: List of Octave MBus meters to read:
01 07

Lua Code Snippet

This piece of Lua code is handling this downlink command.

Lua Code Snippet
elseif rxport == 26 then -- list of primary addresses with octave filtration
_, tmp = pu(rcvd, "b")
print("List of octave primary addresses")
if tmp ~= 0 then
api.setVar(72, "bytes", rcvd)
api.setVar(70, #rcvd, true)
reload = true
end

Setting an Octave filter


This sets an active Octave filter. There are two filters available, with the 1 being the default setting and 2 being the alternative one. Check HEX conversion and Downlink sending guides above to see how use the downlink command.

Here you can see the default filter - this is how the device will read out the meter by default.

The Filter 1 - Default Setting
defaultOctaveFiltration = b(1) -- lorawan port to use = 1
.. b(16) .. b(5) .. b(0x01) .. b(0x03) .. b(0x0D) .. b(0xFD) .. b(0x67) -- Special supplier information
.. b(2) .. b(5) .. b(0x01) .. b(0x03) .. b(0x32) .. b(0xFD) .. b(0x17) -- Error flags
.. b(6) .. b(4) .. b(0x01) .. b(0x02) .. b(0x06) .. b(0x6D) -- Time Point (time & date)
.. b(8) .. b(6) .. b(0x01) .. b(0x04) .. b(0x07) .. b(0x96) .. b(0xBB) .. b(0x73) -- Volume positive
.. b(8) .. b(6) .. b(0x01) .. b(0x04) .. b(0x07) .. b(0x96) .. b(0xBC) .. b(0x73) -- Volume negative
.. b(4) .. b(4) .. b(0x01) .. b(0x02) .. b(0x04) .. b(0x3B) -- Volume Flow
.. b(4) .. b(4) .. b(0x01) .. b(0x02) .. b(0x05) .. b(0x5B) -- Flow Temperature

This is an alternative way to set the filter up. You need to configure the device in order to use it.

The Filter 2 - Alternative Setting
defaultOctaveFiltration = b(2) -- lorawan port to use = 2
.. b(16) .. b(5) .. b(0x01) .. b(0x03) .. b(0x0D) .. b(0xFD) .. b(0x67) -- Special supplier information
.. b(2) .. b(5) .. b(0x01) .. b(0x03) .. b(0x32) .. b(0xFD) .. b(0x17) -- Error flags
.. b(6) .. b(4) .. b(0x01) .. b(0x02) .. b(0x06) .. b(0x6D) -- Time Point (time & date)
.. b(8) .. b(6) .. b(0x01) .. b(0x04) .. b(0x07) .. b(0x96) .. b(0xBB) .. b(0x73) -- Volume positive
.. b(4) .. b(4) .. b(0x01) .. b(0x02) .. b(0x04) .. b(0x3B) -- Volume Flow
.. b(4) .. b(4) .. b(0x01) .. b(0x02) .. b(0x05) .. b(0x5B) -- Flow Temperature
.. b(8) .. b(5) .. b(0x01) .. b(0x03) .. b(0x07) .. b(0x96) .. b(0x73) -- Net Unsigned Volume

Port: 27

Payload: The entire filter in HEX format.

Payload length: 49 Bytes for the default and 48 Bytes for the alternative configuration.

Downlink payloads

Filter 1:

01 10 05 01 03 0D FD 67 02 05 01 03 32 FD 17 06 04 01 02 06 6D 08 06 01 04 07 96 BB 73 08 06 01 04 07 96 BC 73 04 04 01 02 04 3B 04 04 01 02 05 5B
ExampleDescriptionSizeByte Number
0x01LoRaWAN port to use[1B]1
0x10 0x05 0x01 0x03 0x0D 0xFD 0x67Special supplier information[7B]2-8
0x02 0x05 0x01 0x03 0x32 0xFD 0x17Error flags[7B]9-15
0x06 0x04 0x01 0x02 0x06 0x6DTime Point (time & date)[6B]16-21
0x08 0x06 0x01 0x04 0x07 0x96 0xBB 0x73Volume positive[8B]22-29
0x08 0x06 0x01 0x04 0x07 0x96 0xBC 0x73Volume negative[8B]30-37
0x04 0x04 0x01 0x02 0x04 0x3BVolume flow[6B]38-43
0x04 0x04 0x01 0x02 0x05 0x5BFlow Temperature[6B]44-49

Once the downlink payload is sent, the following can be seen if you read the device serial line:

[13:54:26:190] [273581091][STDOUT]: Received Data at Port: 27
[13:54:26:362] 01 10 05 01 03 0D FD 67 02 05 01 03 32 FD 17 06 04 01 02 06 6D 08 06 01 04 07 96 BB 73 08 06 01 04 07 96 BC 73 04 04 01 02 04 3B 04 04 01 02 05 5B
[13:54:26:362] [273581137][STDOUT]: List of Octave filters
[13:54:26:748] [273581462][STDOUT]: Reloading the configuration!
[13:54:26:964] [273581790][STDOUT]: Octave filtration settings:
[13:54:26:964] 01 10 05 01 03 0D FD 67 02 05 01 03 32 FD 17 06 04 01 02 06 6D 08 06 01 04 07 96 BB 73 08 06 01 04 07 96 BC 73 04 04 01 02 04 3B 04 04 01 02 05 5B

Filter 2

02 10 05 01 03 0D FD 67 02 05 01 03 32 FD 17 06 04 01 02 06 6D 08 06 01 04 07 96 BB 73 04 04 01 02 04 3B 04 04 01 02 05 5B 08 05 01 03 07 96 73
ExampleDescriptionSizeByte Number
0x02LoRaWAN port to use[1B]1
0x10 0x05 0x01 0x03 0x0D 0xFD 0x67Special supplier information[7B]2-8
0x02 0x05 0x01 0x03 0x32 0xFD 0x17Error flags[7B]9-15
0x06 0x04 0x01 0x02 0x06 0x6DTime Point (time & date)[6B]16-21
0x08 0x06 0x01 0x04 0x07 0x96 0xBB 0x73Volume positive[8B]22-29
0x04 0x04 0x01 0x02 0x04 0x3BVolume flow[6B]30-35
0x04 0x04 0x01 0x02 0x05 0x5BFlow Temperature[6B]36-41
0x08 0x05 0x01 0x03 0x07 0x96 0x73Net Unsigned Volume[7B]42-48

Once the downlink payload is sent, the following can be seen if you read the device serial line:

[   431043][STDOUT]: Received Data at Port: 27
02 10 05 01 03 0D FD 67 02 05 01 03 32 FD 17 06 04 01 02 06 6D 08 06 01 04 07 96 BB 73 04 04 01 02 04 3B 04 04 01 02 05 5B 08 05 01 03 07 96 73
[ 431089][STDOUT]: List of Octave filters
[ 431393][STDOUT]: Reloading the configuration!
[   431720][STDOUT]: Octave filtration settings:
02 10 05 01 03 0D FD 67 02 05 01 03 32 FD 17 06 04 01 02 06 6D 08 06 01 04 07 96 BB 73 04 04 01 02 04 3B 04 04 01 02 05 5B 08 05 01 03 07 96 73

Lua Code Snippet

This piece of Lua code is handling this downlink command.

Lua Code Snippet
elseif rxport == 27 then -- list of Octave filters
_, tmp = pu(rcvd, "b")
print("List of Octave filters")
if tmp ~= 0 then
api.setVar(102, "bytes", rcvd)
api.setVar(100, #rcvd, true)
reload = true
end

Octave Script Parser

The following section is describing specifics of Javascript parser and its TTN integration.

Octave Filter Parser TTN Setup


The data received from a device using Octave Meters script variant are parsed directly in TTN. First you need to take the script and apply it, as seen in the instructions below and then the data should come in parsed.

Javascript Parser
/**
* Converts a byte array representing a packed date-time into an ISO-formatted string.
* Handles optional flags like summer time and invalid status.
*
* @param {Uint8Array | number[]} byteArray - The input byte array.
* @returns {string|null} ISO-formatted date-time string or null if invalid.
*/
function parsePackedDateTime(byteArray) {
const getLength = (arr) => (arr ? arr.length : 0);

// Convert little-endian byte array to integer
function bytesToIntLE(arr) {
let result = 0;
for (let i = arr.length - 1; i >= 0; i--) {
result = (result << 8) + arr[i];
}
return result;
}

// Decode packed date (year/month/day) from integer
function decodeDate(i) {
if (!i) return null;
const y = ((i >> 5) & 0x07) | ((i >> 9) & 0x78);
const m = (i >> 8) & 0x0F;
const d = i & 0x1F;
const year = 1900 + y + (y < 100 ? 100 : 0);
return { year, month: m, day: d };
}

const length = getLength(byteArray);
const hasExtra = length > 5 ? 1 : 0;
const lastByte = hasExtra ? byteArray[byteArray.length - 1] : 0;

const coreBytes = hasExtra ? byteArray.slice(hasExtra, length - 1) : byteArray;
const packedInt = bytesToIntLE(coreBytes);

if (!packedInt) return null;

const date = decodeDate(packedInt >> 16);
if (!date) return null;

date.hour = (packedInt >> 8) & 0x1F;
date.minute = packedInt & 0x3F;

if (hasExtra) {
date.second = lastByte & 0x3F;
}
if (packedInt & 0x8000) {
date.summer_time = true;
}
if (packedInt & 0x80) {
date.invalid = true;
}

let iso = `${date.year.toString().padStart(4, '0')}-${date.month.toString().padStart(2, '0')}-${date.day.toString().padStart(2, '0')}T${date.hour.toString().padStart(2, '0')}:${date.minute.toString().padStart(2, '0')}`;
if ("second" in date) {
iso += `:${date.second.toString().padStart(2, '0')}`;
}
if (date.summer_time) {
iso += " (summer)";
}
if (date.invalid) {
iso += " (invalid)";
}

return iso;
}

/**
* Parser: Filtered T-MBus Water Meter Data (Legacy-Compatible)
*/
function decodeUplink(input) {
var bytes = input.bytes;
var parsed = {};

// Check if all bytes in a segment are 0xFF (means missing)
function isFF(start, length) {
for (var i = 0; i < length; i++) {
if (bytes[start + i] !== 0xFF) return false;
}
return true;
}

// Decode ASCII characters
function readAscii(start, length) {
if (isFF(start, length)) return null;
var str = '';
for (var i = 0; i < length; i++) {
str += String.fromCharCode(bytes[start + i]);
}
return str;
}

// Convert byte array to uppercase hex string
function readHex(start, length) {
if (isFF(start, length)) return null;
var hex = '';
for (var i = 0; i < length; i++) {
var h = bytes[start + i].toString(16).toUpperCase();
if (h.length < 2) h = '0' + h;
hex += h;
}
return hex;
}

// Parse error flags into human-readable string
function parseErrorFlags(hexStr) {
if (!hexStr) return null;
const value = parseInt(hexStr, 16);
if (value === 0) return "OK";

const flags = [
{ bit: 0x0001, name: "Leakage" },
{ bit: 0x0002, name: "Pipe Burst" },
{ bit: 0x0004, name: "Reverse Flow" },
{ bit: 0x0008, name: "Dry" },
{ bit: 0x0010, name: "Critical Configuration" },
{ bit: 0x0020, name: "Measurement Fail" },
{ bit: 0x0040, name: "Tamper" },
{ bit: 0x0080, name: "Battery" },
{ bit: 0x0100, name: "Units Change" },
{ bit: 0x0200, name: "Watch Dog" },
{ bit: 0x0400, name: "Service Required" }
];

const errors = flags
.filter(flag => value & flag.bit)
.map(flag => flag.name);

return errors.length > 0 ? errors.join("|") : "OK";
}

// Wrapper to use packed datetime parser
function readPackedDateTime(start, length) {
if (isFF(start, length)) return null;
var slice = bytes.slice(start, start + length);
return parsePackedDateTime(slice);
}

// Decode 64-bit little-endian integer
function readInt64LE(start) {
if (isFF(start, 8)) return null;
var value = 0;
for (var i = 7; i >= 0; i--) {
value = value * 256 + bytes[start + i];
}
return value;
}

// Decode 32-bit unsigned little-endian integer
function readUInt32LE(start) {
if (isFF(start, 4)) return null;
var value = 0;
for (var i = 3; i >= 0; i--) {
value = value * 256 + bytes[start + i];
}
return value;
}

// Normalize raw serial to pattern like "ARD2000018147"
function decodeSerial(raw) {
var cleaned = '';
for (var i = raw.length - 1; i >= 0; i--) {
var c = raw.charCodeAt(i);
if (c >= 32 && c <= 126) {
cleaned += raw[i];
}
}

return cleaned;
}

// Start decoding from byte 0
var offset = 0;

if (input.fPort == 1)
{
var serial_raw = readAscii(offset, 16);
parsed.serial = decodeSerial(serial_raw);
offset += 16;

parsed.error_flags = parseErrorFlags(readHex(offset, 2));
parsed.error_flags_raw = readHex(offset, 2);
offset += 2;

parsed.device_time = readPackedDateTime(offset, 6);
offset += 6;

parsed.volume_positive_m3 = readInt64LE(offset) / 1000;
offset += 8;

parsed.volume_negative_m3 = readInt64LE(offset) / 1000;
offset += 8;

parsed.volume_flow_m3h = readUInt32LE(offset) / 1000; // Flow is in thousandths
offset += 4;

parsed.flow_temperature_degC = readUInt32LE(offset) / 1000; // Temperature is in thousandths
offset += 4;
}
else if(input.fPort == 2)
{
var serial_raw = readAscii(offset, 16);
parsed.serial = decodeSerial(serial_raw);
offset += 16;

parsed.error_flags = parseErrorFlags(readHex(offset, 2));
parsed.error_flags_raw = readHex(offset, 2);
offset += 2;

parsed.device_time = readPackedDateTime(offset, 6);
offset += 6;

parsed.volume_positive_m3 = readInt64LE(offset) / 1000;
offset += 8;

parsed.volume_flow_m3h = readUInt32LE(offset) / 1000; // Flow is in thousandths
offset += 4;

parsed.flow_temperature_degC = readUInt32LE(offset) / 1000; // Temperature is in thousandths
offset += 4;

parsed.net_unsigned_volume = readInt64LE(offset) / 1000; // volume
offset += 8;
}
else
{
}

return {
data: parsed,
warnings: [],
errors: []
};
}

Prerequisites

For this, you are going to need to have an The Things Network service, with the device properly registered and receiving. For more information about TTN setup, check the following article.

Setting the Parser for All the Devices

  1. Once the device is registered in TTN, click on Applications and pick the one, where the device is registered.

TTN Step 1

  1. Click on Payload formatters, pick a Formatter type: Custom Javascript formater and copy in the Javascript code above (also included in the script).

TTN Step 2

  1. Now go to End Devices, select the relevant device and and click on Live data. The Payloads should come in parsed.

TTN Step 3

Setting the Parser for the Individual Device

  1. Once the device is registered in TTN, click on Applications and pick the one, where the device is registered.

TTN Step 1

  1. Pick an End device you want to apply parsing to. Click on a Payload Formaters tab.

TTN Step 2

  1. Once there, choose Uplink, then select Custom Javascript formatter and paste in the parser.

TTN Step 3

Once this is done, the uplink payloads coming to the device should be automatically parsed.

Examples of Incoming payload parsing


Once you are in the Device -> Payload Formatters -> Uplink and Custom Javascript formatter is set up, as described in the section above you may also test the parsing directly.

  1. Scroll down until you see Test section.

Test Parse Step 1

  1. Copy a payload from the selection:

Filter 1 example is using from a device using default Filter 1, Filter 2 has been reconfigured via downlink, as described here.

Filter 1 example

00 00 37 34 31 38 31 30 30 30 31 30 44 52 41 38 01 00 01 26 0E 29 35 00 71 E6 3B 01 00 00 00 00 98 93 06 00 00 00 00 00 86 3D 00 00 00 00 00 00

Filter 2 example

00 00 37 34 31 38 31 30 30 30 31 30 44 52 41 38 01 00 01 19 0A 26 35 00 12 97 29 01 00 00 00 00 86 3D 00 00 00 00 00 00 7A 03 23 01 00 00 00 00
How to obtain the payload in HEX from the incoming telegram

Take an incoming uplink message, such as this example:

Details
{
"name": "as.up.data.forward",
"time": "2025-05-09T12:40:13.437187929Z",
"identifiers": [
{
"device_ids": {
"device_id": "lmd-sn-17001",
"application_ids": {
"application_id": "testingrpr"
},
"dev_eui": "70B3D5CEE0000D8A",
"join_eui": "0000000000000000",
"dev_addr": "00647E50"
}
}
],
"data": {
"@type": "type.googleapis.com/ttn.lorawan.v3.ApplicationUp",
"end_device_ids": {
"device_id": "lmd-sn-17001",
"application_ids": {
"application_id": "testingrpr"
},
"dev_eui": "70B3D5CEE0000D8A",
"join_eui": "0000000000000000",
"dev_addr": "00647E50"
},
"correlation_ids": [
"gs:uplink:01JTTGYZ33ZCNS1X9STKFH72FN",
"rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01JTTGYZ385DBMP583CYAWKVJK",
"rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01JTTGYZA6F47215YSRWDP1BGE"
],
"received_at": "2025-05-09T12:40:13.384793834Z",
"uplink_message": {
"session_key_id": "AZakmDvN28ZD3uRLR2tlTA==",
"f_port": 1,
"f_cnt": 456,
"frm_payload": "AAA3NDE4MTAwMDEwRFJBOAEAASsOKTUAUOs7AQAAAACYkwYAAAAAAIY9AAAAAAAA",
"decoded_payload": {
"device_time": "2025-05-09T14:43:00",
"error_flags": "Units Change",
"error_flags_raw": "0100",
"flow_temperature_degC": 0,
"serial": "ARD0100018147",
"volume_flow_m3h": 15.75,
"volume_negative_m3": 431,
"volume_positive_m3": 20704.08
},
"rx_metadata": [
{
"gateway_ids": {
"gateway_id": "rak-rpr-vyvoj",
"eui": "AC1F09FFFE051F8F"
},
"timestamp": 215929931,
"rssi": -87,
"channel_rssi": -87,
"snr": 10,
"uplink_token": "ChsKGQoNcmFrLXJwci12eXZvahIIrB8J//4FH48Qy6j7ZhoLCK3v98AGEM6bkEgg+Im1s6TqrAI=",
"channel_index": 4,
"received_at": "2025-05-09T12:40:13.151260622Z"
}
],
"settings": {
"data_rate": {
"lora": {
"bandwidth": 125000,
"spreading_factor": 7,
"coding_rate": "4/5"
}
},
"frequency": "867300000",
"timestamp": 215929931
},
"received_at": "2025-05-09T12:40:13.161641858Z",
"consumed_airtime": "0.112896s",
"network_ids": {
"net_id": "000000"
}
}
},
"correlation_ids": [
"gs:uplink:01JTTGYZ33ZCNS1X9STKFH72FN",
"rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01JTTGYZ385DBMP583CYAWKVJK",
"rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01JTTGYZA6F47215YSRWDP1BGE"
],
"origin": "e1d41223d5fd",
"context": {},
"visibility": {
"rights": [
"RIGHT_APPLICATION_TRAFFIC_READ"
]
},
"unique_id": "01JTTGYZBXQX5CJNHXVREGSWS1"
}

Find this line:

"frm_payload": "AAA3NDE4MTAwMDEwRFJBOAEAASsOKTUAUOs7AQAAAACYkwYAAAAAAIY9AAAAAAAA",

Copy the contents between the commas.

AAA3NDE4MTAwMDEwRFJBOAEAASsOKTUAUOs7AQAAAACYkwYAAAAAAIY9AAAAAAAA

Use Base64 conversion tool such as this one

The outcome would be:

000037343138313030303130445241380100012b0e29350050eb3b01000000009893060000000000863d000000000000

Copy the outcome into the Byte payload as described in step 3.

  1. Paste the example/or payload into the Byte payload, then make sure you pick FPort 1 for Example 1/Filter 1 payload and FPort 2 for Example 2/Filter 2 payload.

Test Parse Step 3

  1. Now click on the Test decoder button.

Test Parse Step 4

interesting

Here you can see the results of the Example 1 and 2 parsing.

Parsed Result - Using Filter Configuration 1

Byte Payload

00 00 37 34 31 38 31 30 30 30 31 30 44 52 41 38 01 00 01 26 0E 29 35 00 71 E6 3B 01 00 00 00 00 98 93 06 00 00 00 00 00 86 3D 00 00 00 00 00 00

Decoded Test Payload

{
"device_time": "2025-05-09T14:38:00",
"error_flags": "Units Change",
"error_flags_raw": "0100",
"flow_temperature_degC": 0,
"serial": "ARD0100018147",
"volume_flow_m3h": 15.75,
"volume_negative_m3": 431,
"volume_positive_m3": 20702.833
}

Full Payload

{
"name": "as.up.data.forward",
"time": "2025-05-06T08:23:59.210459500Z",
"identifiers": [
{
"device_ids": {
"device_id": "lmd-sn-17001",
"application_ids": {
"application_id": "testingrpr"
},
"dev_eui": "70B3D5CEE0000D8A",
"join_eui": "0000000000000000",
"dev_addr": "00647E50"
}
}
],
"data": {
"@type": "type.googleapis.com/ttn.lorawan.v3.ApplicationUp",
"end_device_ids": {
"device_id": "lmd-sn-17001",
"application_ids": {
"application_id": "testingrpr"
},
"dev_eui": "70B3D5CEE0000D8A",
"join_eui": "0000000000000000",
"dev_addr": "00647E50"
},
"correlation_ids": [
"gs:uplink:01JTJB3M7CJ7D4GCCNYF4JNW1T",
"rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01JTJB3M7E5JH2VRSZ8ZKSC3BG",
"rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01JTJB3MEEZ30CFFRQ34ARP996"
],
"received_at": "2025-05-06T08:23:59.182820861Z",
"uplink_message": {
"session_key_id": "AZakmDvN28ZD3uRLR2tlTA==",
"f_port": 2,
"f_cnt": 9,
"frm_payload": "AAA3NDE4MTAwMDEwRFJBOAEAARkKJjUAEpcpAQAAAACGPQAAAAAAAHoDIwEAAAAA",
"decoded_payload": {
"device_time": "2025-05-06T10:25:00",
"error_flags": "Units Change",
"error_flags_raw": "0100",
"flow_temperature_degC": 0,
"net_unsigned_volume": 19071.866,
"serial": "ARD0100018147",
"volume_flow_m3h": 15.75,
"volume_positive_m3": 19502.866
},
"rx_metadata": [
{
"gateway_ids": {
"gateway_id": "rak-rpr-vyvoj",
"eui": "AC1F09FFFE051F8F"
},
"timestamp": 519634283,
"rssi": -76,
"channel_rssi": -76,
"snr": 8.3,
"uplink_token": "ChsKGQoNcmFrLXJwci12eXZvahIIrB8J//4FH48Q6/rj9wEaDAiejufABhDc54PHAyD40/fkj7PuAQ==",
"channel_index": 3,
"received_at": "2025-05-06T08:23:58.948295039Z"
}
],
"settings": {
"data_rate": {
"lora": {
"bandwidth": 125000,
"spreading_factor": 7,
"coding_rate": "4/5"
}
},
"frequency": "867100000",
"timestamp": 519634283
},
"received_at": "2025-05-06T08:23:58.959097465Z",
"consumed_airtime": "0.112896s",
"network_ids": {
"net_id": "000000"
}
}
},
"correlation_ids": [
"gs:uplink:01JTJB3M7CJ7D4GCCNYF4JNW1T",
"rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01JTJB3M7E5JH2VRSZ8ZKSC3BG",
"rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01JTJB3MEEZ30CFFRQ34ARP996"
],
"origin": "e1d41223d5fd",
"context": {},
"visibility": {
"rights": [
"RIGHT_APPLICATION_TRAFFIC_READ"
]
},
"unique_id": "01JTJB3MFA0QTYM0EANKWSHS72"
}
Parsed Result - Using Filter Configuration 2

Byte Payload

00 00 37 34 31 38 31 30 30 30 31 30 44 52 41 38 01 00 01 19 0A 26 35 00 12 97 29 01 00 00 00 00 86 3D 00 00 00 00 00 00 7A 03 23 01 00 00 00 00

Decoded Test Payload

{
"device_time": "2025-05-06T10:25:00",
"error_flags": "Units Change",
"error_flags_raw": "0100",
"flow_temperature_degC": 0,
"net_unsigned_volume": 19071.866,
"serial": "ARD0100018147",
"volume_flow_m3h": 15.75,
"volume_positive_m3": 19502.866
}

Full Payload

{
"name": "as.up.data.forward",
"time": "2025-05-06T08:23:59.210459500Z",
"identifiers": [
{
"device_ids": {
"device_id": "lmd-sn-17001",
"application_ids": {
"application_id": "testingrpr"
},
"dev_eui": "70B3D5CEE0000D8A",
"join_eui": "0000000000000000",
"dev_addr": "00647E50"
}
}
],
"data": {
"@type": "type.googleapis.com/ttn.lorawan.v3.ApplicationUp",
"end_device_ids": {
"device_id": "lmd-sn-17001",
"application_ids": {
"application_id": "testingrpr"
},
"dev_eui": "70B3D5CEE0000D8A",
"join_eui": "0000000000000000",
"dev_addr": "00647E50"
},
"correlation_ids": [
"gs:uplink:01JTJB3M7CJ7D4GCCNYF4JNW1T",
"rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01JTJB3M7E5JH2VRSZ8ZKSC3BG",
"rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01JTJB3MEEZ30CFFRQ34ARP996"
],
"received_at": "2025-05-06T08:23:59.182820861Z",
"uplink_message": {
"session_key_id": "AZakmDvN28ZD3uRLR2tlTA==",
"f_port": 2,
"f_cnt": 9,
"frm_payload": "AAA3NDE4MTAwMDEwRFJBOAEAARkKJjUAEpcpAQAAAACGPQAAAAAAAHoDIwEAAAAA",
"decoded_payload": {
"device_time": "2025-05-06T10:25:00",
"error_flags": "Units Change",
"error_flags_raw": "0100",
"flow_temperature_degC": 0,
"net_unsigned_volume": 19071.866,
"serial": "ARD0100018147",
"volume_flow_m3h": 15.75,
"volume_positive_m3": 19502.866
},
"rx_metadata": [
{
"gateway_ids": {
"gateway_id": "rak-rpr-vyvoj",
"eui": "AC1F09FFFE051F8F"
},
"timestamp": 519634283,
"rssi": -76,
"channel_rssi": -76,
"snr": 8.3,
"uplink_token": "ChsKGQoNcmFrLXJwci12eXZvahIIrB8J//4FH48Q6/rj9wEaDAiejufABhDc54PHAyD40/fkj7PuAQ==",
"channel_index": 3,
"received_at": "2025-05-06T08:23:58.948295039Z"
}
],
"settings": {
"data_rate": {
"lora": {
"bandwidth": 125000,
"spreading_factor": 7,
"coding_rate": "4/5"
}
},
"frequency": "867100000",
"timestamp": 519634283
},
"received_at": "2025-05-06T08:23:58.959097465Z",
"consumed_airtime": "0.112896s",
"network_ids": {
"net_id": "000000"
}
}
},
"correlation_ids": [
"gs:uplink:01JTJB3M7CJ7D4GCCNYF4JNW1T",
"rpc:/ttn.lorawan.v3.GsNs/HandleUplink:01JTJB3M7E5JH2VRSZ8ZKSC3BG",
"rpc:/ttn.lorawan.v3.NsAs/HandleUplink:01JTJB3MEEZ30CFFRQ34ARP996"
],
"origin": "e1d41223d5fd",
"context": {},
"visibility": {
"rights": [
"RIGHT_APPLICATION_TRAFFIC_READ"
]
},
"unique_id": "01JTJB3MFA0QTYM0EANKWSHS72"
}

Conclusion

Multiframe script is a powerful option for one to one meters installation, as it is power efficient and allows good configuration versatility due to the option simple basic configuration, VIF DIF filters and some more advanced options as well.

Troubleshooting & FAQ


I applied VIF DIF filter but I am getting last error traceback in the serial line - what could be wrong?

  • Please check the function api.mbusVifDifFilter and that the specified number of bytes corresponds with actual number of bytes within the function. The byte counter includes the first byte representing number of VIF DIF values.

I want to apply VIF DIF filter but the documentation does not say anything about M-Bus frames. What should I do?

Where can I see a data or serial line log?

  • You can check any serial line monitor such as PuTTy or Termite. Please make sure the serial line monitor configuration is - baud rate: 115 200, data bits: 8, stop bits: 1, parity: none.

Device is not connecting to the Network Server

  • Make sure that the inserted keys are correct, that the device configured in OTAA and check whether the AppEUI is required. If AppEUI is required, please use the same “0” - 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00.

Device is sending unknown payload

  • Please check if the payload is 4E 4F 20 44 41 54 41 20 52 45 43 45 49 56 45 44 - this translates to “NO DATA RECEIVED” in ASCII.

Device is still sending NO DATA RECEIVED

  • Please check if the M-Bus baudrate and parity configuration is correct and if the electrical connection is done properly. You can also try to change parameter “initialDelay” to a larger value as some meters require up to 6 seconds = 6000 ms. Make sure that no more than one device is connected without any prior changes to configuration.

Device is not connecting to the GUI

  • Make sure to use the Chromium based browser, we strongly recommend to use Google Chrome (other Chromium based browsers still may cause unexpected issues). Also make sure that the serial line is not opened in any other serial line monitor.

Device has connected, Lua script was uploaded but it is not possible to connect anymore

  • Make sure the battery has been disconnected for a longer period of time to discharge the capacitor or alternatively short the battery pins on the PCB. Connect two metal pins in the battery connector on the PCB with something conductive (tip of screw driver, paper clip, tip of a pen…). The device can connect only when in bootloader or when it is sleeping. If the device is in the application Lua script and currently running, it will not connect.

Where do I configure the Lua script?

  • Please, make sure to use a Chromium based browser, we strongly recommend to use Google Chrome.

I am getting a one-byte answer. What does it mean?

  • The 1-byte answer usually means there is a collision on the M-Bus line. This usually occurs when more than one M-Bus meter is connected and the M-Bus converter is sending a broadcast query on address 254. The exception might be a “0xE5” which is a confirmation from the meter according to M-Bus standard.


Was anything unclear, missing or hard to understand? Please, contact us at support@acrios.com.
Further information can be found on wiki.acrios.com.